Skip to content

feat(content-drive): add Action Center for bulk workflow actions - #36821

Merged
rjvelazco merged 20 commits into
mainfrom
issue-36817-content-drive-action-center-bulk-workflow-actions-with-one-action-per-execution
Aug 4, 2026
Merged

feat(content-drive): add Action Center for bulk workflow actions#36821
rjvelazco merged 20 commits into
mainfrom
issue-36817-content-drive-action-center-bulk-workflow-actions-with-one-action-per-execution

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Jul 30, 2026

Copy link
Copy Markdown
Member

Draft implementation of the Content Drive Action Center — a dialog for acting on a multi-item selection.

Opening as a draft: this is a working v1 to demo and discuss with the team, with a few deliberate scope limits documented below.

Fixes #36817

Proposed Changes

  • getBulkActions() on DotWorkflowsActionsService — wraps POST /api/v1/workflow/contentlet/actions/bulk, which returns the workflow actions available for a set of contentlets, grouped by scheme and step, each with the number of selected contentlets it applies to. New DotBulkActionView models in dotcms-models.
  • DotContentDriveActionCenterComponent — the dialog, with two sections:
    • Quick Actions — Publish / Unpublish / Archive / Unarchive / Delete, each fired over the whole eligible selection in one request via POST /api/v1/workflow/actions/default/fire/{systemAction}. Counts are derived client-side from row state (live, archived), and an action that applies to nothing is omitted rather than shown as (0).
    • Workflow Actions — one collapsible panel per workflow scheme, with real per-action eligibility counts from the backend's Elasticsearch aggregation on wfstep. Single-expand; one action selected at a time.
  • Toolbar — once more than one contentlet is selected, the flat action buttons are replaced by a single "Action Center" button. Folders are excluded from the count and from every payload, since the bulk endpoints take contentlet inodes and folders carry no workflow step.
  • ShellDIALOG_TYPE.ACTION_CENTER is routed out of the shared dialog's content switch and mounted as a sibling, because this dialog owns a custom header and footer (see Additional Info). The store stays the single source of open/close state.
  • i18n — 19 content-drive.action-center.* keys.

Checklist

  • Tests — 37 new (action-center.spec.ts for the pure mapping/eligibility logic, plus the component spec). Full portlet suite green: 872/872, lint clean.
  • Translations — English keys added to Language.properties; other locales not translated.
  • Security Implications Contemplated — no new endpoints and no new permissions surface; this consumes existing authenticated REST endpoints. Worth knowing for review: the bulk-actions endpoint filters by action-level role permission only and deliberately lets actions with special workflow roles through, because there is no per-contentlet permissionable at aggregation time (WorkflowActionUtils#filterBulkActions). So a listed action can still fail per item at fire time, which the backend reports in fails[] and the dialog surfaces. Enforcement stays server-side; the counts here are advisory, never an authorization decision.

Additional Info

Scope limits, all deliberate:

  • One action per execute. No endpoint fires several different actions in one call, and this isn't just a missing endpoint — workflow_task has a UNIQUE (webasset, language_id) constraint, so a contentlet+language sits on exactly one step, owned by exactly one scheme. Firing action A moves it to a new step, which invalidates the other actions' preconditions. The legacy JSP dialog has the same constraint. A useful consequence: the scheme counts partition the selection rather than overlapping.
  • Actions needing extra input are disabled with a tooltip — push-publish settings, a move target path, or an assign/comment prompt. Wiring those means reusing DotWorkflowEventHandlerService; out of scope here.
  • Conditional counts are shown as ≤ N. When conditionPresent is true the backend does not evaluate the action's Velocity condition while aggregating, so the count is an upper bound.
  • Lock / Unlock and Add to Bundle are omitted — neither has a bulk REST endpoint. Legacy drives unlock through a Struts command (full_unlock_list) that loops server-side, and add-to-bundle through the legacy RemotePublishAjaxAction servlet.
  • Fires synchronously via bulkFire. The legacy dialog uses the SSE endpoint (_bulkfire) for live progress counters, which is the better path for large batches but needs an SSE shim since native EventSource cannot POST a body.
  • System Workflow stays visible. Hiding it would remove bulk Copy outright — Copy has no system-action mapping, so it is only reachable through the workflow list — and would empty the section on Community, where System Workflow is the only scheme that can exist.

Known follow-ups (backend asks):

  • _bulklock / _bulkunlock REST endpoints (the _batchUnlock logic already exists to port).
  • An endpoint returning eligible inodes per action — the only thing that unblocks the design's per-item drill-down, which needs to know which items an action applies to. The API returns aggregate counts only.
  • A REST replacement for the legacy add-to-bundle AJAX action.

Deviations from the design prototype, both following from the above: workflow steps use radio semantics rather than checkboxes (multi-select would need sequential fires with re-validation between each), and there are no per-step icon chips (the API returns dotCMS icon names that don't map to Material Symbols).

Styling note: the prototype's raw slate-* / #1D1B4B / #1D4ED8 values are mapped onto the theme tokens tailwindcss-primeui exposes (surface-*, primary-*), so the dialog follows the active PrimeNG theme instead of pinning one palette. If that navy is meant to be a real brand token, that's a theme-level change rather than something hardcoded here.

Video

video.mov

This PR fixes: #36817

rjvelazco and others added 8 commits July 29, 2026 16:50
Adds the client side of POST /api/v1/workflow/contentlet/actions/bulk,
which returns the workflow actions available for a set of contentlets
grouped by scheme and step, each with the number of selected contentlets
it applies to.

The models capture two properties of the response that matter to callers:

- An action's `count` is already summed across every step of its scheme,
  so flattening steps into one list per scheme does not double-count.
- `conditionPresent` means the count is an upper bound. The backend does
  not evaluate the action's Velocity condition while aggregating, since
  there is no per-contentlet permissionable at that point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draft of the Action Center: a dialog for acting on a multi-item selection,
opened from the toolbar once more than one contentlet is selected. Built
with PrimeNG (accordion, radiobutton, badge, message, skeleton) and
Tailwind for layout only.

Quick Actions fires system actions over the whole eligible selection in
one request. Counts are derived client-side from row state, and an action
that applies to nothing is omitted rather than shown as "(0)".

Workflow Actions renders one collapsible panel per scheme from the bulk
actions endpoint, with real per-action eligibility counts. Steps are
flattened into a single list per scheme, since the step grouping is a
backend detail the dialog does not need to surface.

Scope limits, all deliberate:

- One action per execute. No endpoint fires several different actions in
  one call, and firing one moves contentlets to a new step, which
  invalidates the other counts. The legacy JSP dialog works the same way.
- Actions needing extra input (push publish, move path, assign/comment)
  are disabled with a tooltip rather than reimplementing the params
  dialog. Conditional counts render as "<= N".
- Lock/Unlock and Add to Bundle are omitted: neither has a bulk REST
  endpoint. Legacy drives unlock through a Struts command that loops
  server-side, and add-to-bundle through a legacy AJAX servlet.
- Fires synchronously. Legacy uses the SSE endpoint for live progress,
  which is the better path for large batches but needs an SSE shim.
- Folders are excluded from every payload, matching the endpoints.

System Workflow is intentionally left visible: hiding it would remove
bulk Copy entirely (it has no system-action mapping) and would empty the
section on Community, where it is the only scheme that can exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses design feedback on the dialog shell.

The Action Center now owns its own p-dialog instead of rendering inside
the shell's shared one. That shared dialog gives a title and a scrolling
content area; this dialog needs a custom header, a custom footer, and a
body that is the only scrollable region. Two constraints forced the split:

- PrimeNG queries `#header` / `#footer` with `descendants: false`, so the
  templates must be direct children of `p-dialog` and cannot sit inside
  the shell's `@switch` over dialog types.
- Providing a `#footer` template on the shared dialog would render an
  empty footer for the folder and content-type dialogs.

The shell routes ACTION_CENTER out of the shared dialog's content switch
and mounts the component as a sibling instead, keeping the store as the
single source of open/close state.

Layout changes:

- Header carries the title and the selected-contentlet count.
- Footer carries the "one at a time" hint and Done, pinned.
- Content area is `p-0 overflow-hidden` via `pt`; an inner div owns
  `max-h-[60vh] overflow-y-auto`, so only the body scrolls.
- Quick action rows are plain buttons styled with Tailwind to match the
  design: a Material Symbols icon chip, left-aligned label, count and
  chevron trailing, red chip and label for destructive actions. They were
  p-buttons before, which centered their content and could not express
  the chip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restyles the dialog against the prototype in
remix_-content-drive/components/ActionCenterDialog.tsx.

The prototype's raw palette is mapped onto the theme tokens that
tailwindcss-primeui exposes — `surface-*`, `primary-*`, `border-surface`
— rather than copying its `slate-*` and hardcoded `#1D1B4B` / `#1D4ED8`
values, so the dialog follows the active PrimeNG theme.

- Fixed-height flex column (`80vh`, `42rem` wide) with the content area
  flexing and scrolling. Replaces the `max-h-[60vh]` guess on an inner
  div; this is how the prototype does it and it keeps header and footer
  pinned without a second scroll container.
- Card treatment: sections sit on `surface-50` inside a rounded-xl
  `surface-100` border, and rows lift to `surface-0` on hover. This was
  inverted before (white card, grey hover).
- Row metrics from the prototype: `gap-4`, `py-3.5`, 20px icon glyphs,
  `size-9` chips with `shadow-sm`, 10px bold section labels.
- Scheme panels are now single-expand, matching the prototype: opening one
  collapses the rest, and the expanded scheme's name takes the primary
  colour. Collapsing a panel clears its pending action so Execute cannot
  stay armed for a hidden panel.

Two deliberate deviations from the prototype, both carried over from the
endpoint analysis:

- Steps use radio semantics, not checkboxes. The prototype multi-selects
  steps, but no endpoint fires several actions in one call and firing one
  moves contentlets to a new step, invalidating the other counts.
- No per-step icon chips. The prototype hand-picks a glyph per step; the
  API returns dotCMS icon names that do not map to Material Symbols, so
  there is nothing to render faithfully yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A collapsed scheme panel in the Action Center still took up the height of
its expanded content, leaving a large blank gap under the header.

PrimeNG 21 collapses accordion content by animating the motion wrapper's
`grid-template-rows` to `0fr`, but that wrapper is configured with
`hideStrategy: 'visibility'` and `unmountOnLeave: false`, so it stays
mounted and keeps its layout box. Without `overflow: hidden` the content
simply overflows the zero-height grid row and the panel keeps its full
height. PrimeNG's own stylesheet only ever sets `grid-template-rows: 1fr`
on `.p-accordioncontent .p-motion`, so nothing clips it by default.

Fixed with the same `pt` override already used by
dot-page-scanner-a11y-report:

    { motion: { root: { style: { overflow: 'hidden' } } } }

Also adopts that component's `dt` content-padding reset so the rows own
their padding, which restores the design's full-bleed dividers, and makes
`[multiple]="false"` explicit rather than relying on the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scheme panels rendered as borderless grey cards. A `border` utility on
the panel had no effect: the theme ships `accordion.panel.border.width` as
`0 0 1px 0` — inter-panel dividers, which suit one stacked accordion — and
its runtime-injected CSS wins over a utility class.

Overriding the design token instead gives each panel a real border, which
is what the design shows: the schemes are separate cards separated by a
gap, not a stack.

    panel: { borderWidth: '1px', borderColor: '{surface.200}' }

The content section also gets a top border so an expanded header is
separated from its rows, and its background moves to the token rather than
a utility on an inner div, keeping one source of truth per surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the scheme panels the same radius and background treatment as the
Quick Actions list, and fixes the reason they did not match.

`p-accordion-panel` exposes only a `value` input — it has no `styleClass`
— so the `rounded-xl bg-surface-50 overflow-hidden` attribute on it was
inert. It has been doing nothing since the panels were introduced; the
grey came from the theme's header background, not from that class. Panel
styling now goes through the `panel` pass-through section, where
`overflow: hidden` is also what clips the header and content corners to
the panel radius.

This also corrects the reasoning in 2a4ebf1: the panel border did not
lose a specificity fight with the theme, the class was never applied at
all. Overriding `accordion.panel.border.*` remains the right fix, since
the theme's `0 0 1px 0` default is meant for a stacked accordion.

Header backgrounds now come from tokens and mirror the Quick Actions rows:
`surface-50` at rest, white on hover, and white while expanded so the open
scheme reads as the active card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hrough

Applies the scheme panel's radius via `p-accordion-panel`'s `pt.root`
instead of the parent accordion's `pt.panel`, keeping the override on the
component it targets. Also drops a stray unused `PanelClasses` import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion Center

The Action Center button was replacing the flat workflow action buttons
once more than one contentlet was selected, so the quick per-item actions
disappeared exactly when a multi-selection was made.

They are now offered together: the workflow action buttons stay visible for
any selection and keep their place first in the toolbar row, with the
Action Center button appended after them. The dialog adds what the flat
buttons cannot express — per-action eligibility counts and the workflow
actions grouped by scheme — rather than standing in for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 3m 34s —— View job


Review: Action Center for bulk workflow actions

Re-reviewed against origin/main after the latest changes (dialog reuse, eligibility fix, error-manager wiring).

New Issues

  • 🟡 Medium: dot-content-drive-action-center.component.ts:310-314 — the bulkFire success branch drops result.fails[]. onExecuteSuccess reads only successCount / skippedCount, so per-item fire-time failures (e.g. the permission failures the PR description says the dialog "surfaces") are silently swallowed. Worse, next always calls onExecuteSuccess, so a response of { successCount: 0, skippedCount: 0, fails: [...] } still renders a green "Action executed — ran on 0 item(s)" toast. The shell's own onMoveItems (dot-content-drive-shell.component.ts:834) already iterates fails.forEach(...) and reports each one — this path should do the same, or at minimum branch on successCount === 0. Fix this →

  • 🔵 Minor: Language.properties:7172-7173content-drive.action-center.toast.error / .toast.error-detail were added but are now dead keys: onExecuteError routes through DotHttpErrorManagerService.handle(error) and never reads them. Remove them, or leave a note that they're reserved.

Resolved

  • dot-content-drive-action-center.component.ts:237-244 — quick actions now fire over quickAction.eligibleInodes (not the whole selection), and the success toast reports inodes.length. The earlier High eligibility mismatch is fixed, with a spec asserting Publish fires only the eligible inode.
  • dot-content-drive-action-center.component.ts:455-457 — error handling now uses DotHttpErrorManagerService.handle(error) instead of a bespoke MessageService toast + console.error, matching the portlet convention.
  • dot-content-drive-action-center.component.ts:194-198$quickActions now feeds getQuickActions(this.$contentlets()), so folder exclusion is derived once instead of twice.
  • dot-content-drive-shell.component.html:137-139 — the Action Center is now rendered inside the shared p-dialog (reviewer request), removing the duplicated dialog/open-close logic.
  • ✅ Test coverage the earlier review flagged as missing is present: getBulkActions (dot-workflows-actions.service.spec.ts:115), the toolbar Action Center gating/open (dot-content-drive-toolbar.component.spec.ts:425-461), and the shell ACTION_CENTER routing (dot-content-drive-shell.component.spec.ts:434-496).

Nothing blocking. The fails[] gap (Medium) is the one worth addressing before this leaves draft, since the PR description advertises that the dialog surfaces per-item failures.

rjvelazco and others added 2 commits July 30, 2026 16:20
…tlet

Drops the Action Center button's threshold from more than one contentlet to
one or more, so it is available alongside the flat action buttons for a
single selection.

This widens the original scope, which specced the button as appearing only
for a multi-selection. It holds up: the dialog's value is the per-action
eligibility counts and the workflow actions grouped by scheme, and neither
depends on the selection having more than one item. The bulk endpoints take
an inode list, so a single inode needs no special handling.

Folders still do not count — a folder-only selection offers no Action
Center, since every bulk endpoint ignores folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck actions visible

Two pieces of design feedback.

**Renamed to "Workflow Center"** and dropped the toolbar button's icon. The
button and the dialog header share one message key, so the label change
covers both and they stay consistent.

**Quick actions are no longer filtered out when they apply to nothing.**
Every action is always listed; a count of 0 now means "does not apply to
this selection" and the row renders non-selectable, with a tooltip saying
so. Previously such rows were dropped, which made the list shift as the
selection changed — rows appearing and disappearing under the pointer, and
no indication that an action existed but was unavailable. An empty result
still means there are no contentlets at all, where nothing could apply.

Note this makes the count honest rather than decorative: a disabled row is
the one place the UI admits an action is unavailable for the whole
selection, which the previous behaviour hid entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjvelazco and others added 2 commits July 31, 2026 13:08
…x the order

Adds Add to Bundle to the quick actions, always non-selectable with a hint
explaining why, and pins the display order to Publish, Unpublish, Archive,
Delete, Add to Bundle. Rows hold that position whether or not they are
selectable, so the list never reshuffles as the selection changes.

Add to Bundle is not blocked on an endpoint: `POST /api/v1/bundles/assets`
takes a list of asset **identifiers** (note: identifiers, unlike the
workflow bulk endpoints, which take inodes). What it needs is a target
bundle, so a picker step — `DotAddToBundleComponent` accepts a single
identifier and still posts to the legacy AJAX servlet — plus an
enterprise-license gate. That is a shared-component change and is tracked
separately, hence the disabled row rather than a half-wired one.

A `pendingHint` on the action models "no working implementation yet",
distinct from a zero count meaning "does not apply to this selection". The
hint takes precedence, so the row explains itself rather than blaming the
selection, and the execute handler guards on it as well as on the disabled
attribute.

This drops **Unarchive**, which is not in the design's quick-action set. It
is a valid SystemAction and was working, so re-adding it is a one-line
change if that set is revisited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Puts Unarchive back, positioned after Delete so the two archived-only
actions sit together and Add to Bundle stays last.

Without it, archiving was a one-way trip: Archive shows for items that are
not archived and Delete for items that are, but nothing in the dialog could
un-archive. Unarchive is a valid SystemAction the multi-contentlet fire
endpoint accepts, so it needs no extra plumbing.

Order is now Publish, Unpublish, Archive, Delete, Unarchive, Add to Bundle,
asserted by the fixed-order test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… owns

Removes Publish, Unpublish, Archive, Unarchive and Delete from the
toolbar's flat action buttons. They are offered by the Workflow Center's
Quick Actions, which shows how many of the selected items each one applies
to — something a flat button cannot express. Keeping both meant the same
action appeared twice, reached by two different code paths.

The toolbar keeps what the dialog does not cover: Edit Content, Edit Page,
Rename, Save as Draft and Download.

Two consequences handled rather than shipped:

- **Quick action labels no longer borrow from the toolbar list.** They were
  looked up in `DEFAULT_WORKFLOW_ACTIONS` by id, so trimming that array
  would have rendered raw ids ("PUBLISH"). Each quick action now carries its
  own `nameKey`, reusing the same `Default-Action-*` message keys.
- **Bulk Delete keeps its confirmation.** The toolbar's Delete prompted
  before firing; moving it would have left the only delete path unguarded.
  The Action Center now confirms any quick action carrying a
  `confirmMessage`, which is Delete alone, matching the previous behaviour.

Test changes: the toolbar spec used Publish and Archive as stand-ins for
"a workflow action" in its execute, toast and error tests — those are
repointed to Save as Draft, which fires through the same path. The two tests
whose subject was specifically a removed action are gone, and Delete's
confirm-then-fire flow is now covered in the Action Center spec, including
the dismissal case. The toolbar's confirm branch is left in place as the
mechanism a future confirmable action would use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco marked this pull request as ready for review July 31, 2026 18:00
@rjvelazco

Copy link
Copy Markdown
Member Author

@claude core review this PR and propose some unit test

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 6m 31s —— View job


Review: Action Center for bulk workflow actions

Todo list

  • Gather context (diff, changed files, related utils/component/service code)
  • Review core logic: action-center.ts, the dialog component, DotWorkflowsActionsService, toolbar/shell wiring
  • Check status of previously-flagged High issue (quick actions firing over ineligible items)
  • Propose additional unit tests
  • Post final review

Status of the earlier High finding

Still open. onExecuteQuickAction (dot-content-drive-action-center.component.ts:222) builds inodes from toContentletInodes(this.$selectedItems()) — the whole selection — while the displayed count (action-center.ts:173) is contentlets.filter(quickAction.eligibleWhen).length. The component's own spec still locks in the mismatch: inode-1 (not live) + inode-2 (live: true) → Publish renders (1) but fireDefaultAction is asserted called with ['inode-1', 'inode-2'] (spec lines 308–317). Firing a lifecycle action (e.g. DELETE) over an item that doesn't satisfy eligibleWhen contradicts the PR description ("fired over the whole eligible selection") and can mutate items the UI never advertised as affected. Worth fixing before merge — see the proposed regression test below.

New/confirmed findings

  • 🟡 Mediumdot-content-drive-action-center.component.ts:437-445 (onExecuteError): custom MessageService toast + console.error instead of DotHttpErrorManagerService.handle(error). libs/portlets/CLAUDE.md's anti-pattern table calls this out explicitly ("Custom error dialogs" → "Use DotHttpErrorManagerService.handle(error) everywhere"), and it's the pattern already used elsewhere in this same portlet (e.g. dot-content-drive-navigation.service.ts). A generic "Action failed" toast also masks 401/403 differently than the error manager would, which matters here specifically since the PR notes a listed action can fail per-item on a permission check at fire time.
  • 🔵 Minordot-content-drive-action-center.component.ts:184-186: $quickActions computes from getQuickActions(this.$selectedItems()) while $contentlets() already holds excludeFolders(this.$selectedItems()). getQuickActions re-derives the same folder exclusion internally, so behavior is correct, just redundant — getQuickActions could take this.$contentlets() instead of walking the raw selection twice. Fix this →
  • No security regressions found beyond what's already noted in the PR description — enforcement stays server-side, folder exclusion is applied consistently across every payload (toContentletInodes, excludeFolders), and the confirm-before-destructive-action behavior (Delete) matches the toolbar's prior behavior.

Test coverage gaps

The new specs (action-center.spec.ts — pure mapping, and the component spec) are solid for the mapping/eligibility logic and the dialog's own behavior, but a few things introduced or changed by this PR aren't exercised anywhere:

  1. DotWorkflowsActionsService.getBulkActions (dot-workflows-actions.service.ts:100) has no test. dot-workflows-actions.service.spec.ts covers every other method on this service via createHttpFactory/expectOne but wasn't updated for the new one.
  2. Toolbar → Action Center wiring (dot-content-drive-toolbar.component.ts:287-299, $displayActionCenter / onOpenActionCenter) has no test in dot-content-drive-toolbar.component.spec.ts.
  3. Shell routing of DIALOG_TYPE.ACTION_CENTER (dot-content-drive-shell.component.ts:197-207, the $isActionCenter/$sharedDialogVisible/$actionCenterVisible split) isn't covered by dot-content-drive-shell.component.spec.ts.
  4. The eligibility bug above has no regression test once fixed.

Proposed unit tests

1. Regression test for the eligibility bug (add to dot-content-drive-action-center.component.spec.ts, once the fix lands):

it('should fire the quick action only over eligible inodes', () => {
    // inode-1 is not live (eligible for Publish); inode-2 is already live (not eligible)
    spectator.detectChanges();

    spectator.click('[data-testid="quick-action-PUBLISH"]');

    expect(fireService.fireDefaultAction).toHaveBeenCalledWith({
        action: 'PUBLISH',
        inodes: ['inode-1'] // NOT inode-2
    });
});

it('should report the eligible count in the success toast, not the full selection size', () => {
    spectator.detectChanges();
    spectator.click('[data-testid="quick-action-PUBLISH"]');

    expect(messageService.add).toHaveBeenCalledWith(
        expect.objectContaining({
            detail: expect.stringContaining('1') // eligible count, not 2
        })
    );
});

2. DotWorkflowsActionsService.getBulkActions (add to dot-workflows-actions.service.spec.ts):

it('should post contentletIds and unwrap the entity', (done) => {
    const request = { contentletIds: ['inode-1', 'inode-2'] };
    const mockResponse = { schemes: [] };

    spectator.service.getBulkActions(request).subscribe((res) => {
        expect(res).toEqual(mockResponse);
        done();
    });

    spectator
        .expectOne('/api/v1/workflow/contentlet/actions/bulk', HttpMethod.POST)
        .flush({ entity: mockResponse });
});

it('should fall back to an empty schemes array when entity is missing', (done) => {
    spectator.service.getBulkActions({ contentletIds: ['a'] }).subscribe((res) => {
        expect(res).toEqual({ schemes: [] });
        done();
    });

    spectator
        .expectOne('/api/v1/workflow/contentlet/actions/bulk', HttpMethod.POST)
        .flush({});
});

3. Toolbar → Action Center gating (add to dot-content-drive-toolbar.component.spec.ts):

it('should hide the Action Center button for a folder-only selection', () => {
    mockSelectedItems.set([{ type: 'folder', inode: 'f1' } as DotContentDriveItem]);
    spectator.detectChanges();

    expect(spectator.query('[data-testid="action-center-button"]')).toBeFalsy();
});

it('should show the Action Center button once a contentlet is selected', () => {
    mockSelectedItems.set([{ baseType: 'CONTENT', inode: 'c1' } as DotContentDriveItem]);
    spectator.detectChanges();

    expect(spectator.query('[data-testid="action-center-button"]')).toBeTruthy();
});

it('should open the ACTION_CENTER dialog on click', () => {
    mockSelectedItems.set([{ baseType: 'CONTENT', inode: 'c1' } as DotContentDriveItem]);
    spectator.detectChanges();

    spectator.click('[data-testid="action-center-button"]');

    expect(store.setDialog).toHaveBeenCalledWith(
        expect.objectContaining({ type: DIALOG_TYPE.ACTION_CENTER })
    );
});

4. Shell dialog routing (add to dot-content-drive-shell.component.spec.ts):

it('should mount the Action Center as a sibling, not inside the shared dialog switch', () => {
    mockDialog.set({ type: DIALOG_TYPE.ACTION_CENTER, header: 'x' });
    spectator.detectChanges();

    expect(spectator.query('dot-content-drive-action-center')).toBeTruthy();

    const sharedDialog = spectator.query('[data-testId="dialog"]');
    expect(sharedDialog?.getAttribute('ng-reflect-visible')).not.toBe('true');
});

it('should route FOLDER and CONTENT_TYPE_SELECTOR through the shared dialog switch, unaffected', () => {
    mockDialog.set({ type: DIALOG_TYPE.FOLDER, header: 'x' });
    spectator.detectChanges();

    expect(spectator.query('dot-content-drive-action-center')).toBeFalsy();
    expect(spectator.query('dot-content-drive-dialog-folder')).toBeTruthy();
});

(Test 4's exact assertions on visible/attribute names may need adjusting to match how the shell spec's existing mocks expose the dialog — I didn't find that spec's helper setup in this pass, so the above should be read as intent rather than drop-in code.)


rjvelazco and others added 3 commits August 3, 2026 10:35
…w-fire path

Save as Draft is no longer needed in the toolbar. With it gone, every
remaining toolbar action is navigation, rename or download — none of them
fires a workflow action — so the machinery behind that path goes too:
`DotWorkflowActionsFireService`, the confirmation service and dialog, the
`confirmationMessage` field on `ContentDriveWorkflowAction`, and the
`executeWorkflowAction` / `performWorkflowAction` / `beforeExecuteWorkflowAction`
chain.

The component is now purely presentational dispatch: edit, rename, download.
Lifecycle actions live in the Workflow Center, which owns their confirmation
and error handling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the PR review.

**Quick actions fired over the whole selection while the row showed only
the eligible count.** `onExecuteQuickAction` built its payload from
`toContentletInodes($selectedItems())`, but the badge came from
`filter(eligibleWhen).length` — two independent derivations. A Publish
reading "(1)" would publish two items, and Delete would be attempted on
contentlets that are not archived.

The eligible inodes are now derived alongside the count in a single filter
pass and carried on the action as `eligibleInodes`, which is exactly what
gets fired. One source, so the number shown and the items touched cannot
drift apart. The success toast reports the same set.

Two regression tests lock this down: one asserts Publish sends only the
non-live inode, another parses the count out of the rendered row and
asserts the fired payload has that many inodes. The previous spec asserted
the buggy payload, which is why this passed review-less.

Also from the review:

- Errors now go through `DotHttpErrorManagerService.handle()` instead of a
  bespoke toast plus `console.error`. Beyond the documented anti-pattern,
  it matters here: per-item permission failures are an expected outcome of
  these endpoints, and the manager distinguishes 401/403 rather than
  flattening everything into one message.
- `getQuickActions` is fed `$contentlets()` rather than re-deriving folder
  exclusion from the raw selection.

New specs for the three untested seams: `getBulkActions` (inode and query
variants, plus the null-entity fallback), the toolbar's Workflow Center
gating and dialog wiring, and the shell's ACTION_CENTER routing including
that the shared dialog stays hidden while it is active.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-workflow-actions-with-one-action-per-execution
Addresses review feedback (zJaaal) on both the dialog template and the
shell: reuse the existing content-drive dialog instead of a second
`p-dialog`, so there is no duplicate dialog logic.

The Action Center is a case in the shared dialog's content switch again.
`$sharedDialogVisible` / `$actionCenterVisible` / `$isActionCenter` are gone
along with the sibling mount, and `onVisibleChange` no longer needs its
guard — there is one dialog and one open/close path.

The pinned-chrome layout survives without PrimeNG's `#header` / `#footer`
slots, which was the reason I reached for a second dialog in the first
place. Those slots are `ContentChild` queries with `descendants: false`, so
they cannot be provided from inside the shell's `@switch`, and adding a
footer slot would render an empty footer for the folder and content-type
dialogs. Making the *content box* a flex column achieves the same thing:

- The shell gives this type `flex flex-col overflow-hidden p-0!` plus a
  fixed height (the column needs something to flex against, or the body
  never scrolls), and drops the header's bottom rule so the title and the
  "N items selected" line read as one block.
- This component supplies the three rows: a pinned summary, a
  `flex-1 overflow-y-auto` body, and a pinned footer.

Other dialog types are untouched — they get no style overrides and no
footer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… into the header

Three related symptoms, one cause.

The content box was scrolling as a whole — footer included — because the
theme sets `overflow-y: auto` on `.p-dialog-content` and its
runtime-injected CSS outranks a Tailwind `overflow-hidden` utility. The flex
column never took effect, so the footer read as part of the content and the
scrollbar spanned it.

Fixed by driving the content box through `[contentStyle]` instead. Inline
styles beat the theme's class rules without `!` overrides:

    display: flex; flex-direction: column; flex: 1;
    min-height: 0; overflow: hidden; padding: 0

`min-height: 0` is the load-bearing part — a flex item will not shrink below
its content height without it, so the body could never scroll and the footer
was pushed out of view. The body is now the only scroll container and the
footer is pinned as its sibling.

The "N items selected" line moves into a real custom header via PrimeNG's
`#header` slot, which is where it belongs — previously it sat at the top of
the content and scrolled away, which is why it was missing from the report.
The slot is a `ContentChild` with `descendants: false`, so it lives as a
single direct child of `p-dialog` with the dialog type switched inside it;
providing it suppresses PrimeNG's own title span, hence the default branch
renders the title itself. The header's bottom rule is dropped for this type
so title and sub-line read as one block.

The footer stays a flex sibling rather than using the `#footer` slot, so the
Done button can remain disabled while an action is in flight — that state
lives in the dialog component, not the shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal
zJaaal self-requested a review August 3, 2026 17:42
@rjvelazco
rjvelazco added this pull request to the merge queue Aug 3, 2026
@mergify

mergify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Aug 3, 2026
@rjvelazco
rjvelazco added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit d595cd8 Aug 4, 2026
66 checks passed
@rjvelazco
rjvelazco deleted the issue-36817-content-drive-action-center-bulk-workflow-actions-with-one-action-per-execution branch August 4, 2026 00:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Content Drive Action Center: bulk workflow actions with one action per execution

2 participants